Skip to content

fix(server): align ProtocolError re-throw with spec error classification#1769

Open
felixweinberger wants to merge 2 commits intomainfrom
fweinberger/protocol-error-rethrow
Open

fix(server): align ProtocolError re-throw with spec error classification#1769
felixweinberger wants to merge 2 commits intomainfrom
fweinberger/protocol-error-rethrow

Conversation

@felixweinberger
Copy link
Contributor

Re-throws all ProtocolError instances from the tools/call handler as JSON-RPC errors. Previously only UrlElicitationRequired was re-thrown; other ProtocolErrors thrown inside the try block (output validation, task misconfiguration) were silently wrapped as isError: true tool results.

Motivation and Context

The MCP spec classifies tool errors into two categories:

  • Protocol errors (JSON-RPC): unknown tool, malformed requests, server errors
  • Tool execution errors (isError: true): API failures, input validation errors, business logic errors

The current catch block only re-throws UrlElicitationRequired, which means output validation failures (a server-side bug) and task misconfiguration errors get demoted to tool-level isError: true results when they should be protocol-level JSON-RPC errors.

This also means tool handlers that deliberately throw new ProtocolError(...) get their intent overridden — the python-sdk re-throws all MCPError in the equivalent path.

How Has This Been Tested?

Updated existing integration tests to reflect the new behavior. All tests pass locally.

Breaking Changes

Yes — output validation failures and task-required-without-task now throw ProtocolError instead of returning { isError: true }. See migration guide updates.

Input validation behavior is unchanged (still isError: true, per spec).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Surfaced while triaging #1674, which proposed the broadened re-throw but would have also promoted input validation to JSON-RPC (spec violation). This PR applies the broadening while keeping input validation tool-level by changing validateToolInput to throw plain Error instead of ProtocolError.

Re-throw all ProtocolError instances from the tools/call handler as
JSON-RPC errors. Previously only UrlElicitationRequired was re-thrown;
other ProtocolErrors thrown inside the try block (output validation,
task misconfiguration) were wrapped as isError: true tool results.

Per the MCP spec's error classification:
- Input validation failures are tool-execution errors (isError: true)
- Output validation failures are server errors (JSON-RPC InternalError)
- Task misconfiguration is a protocol mismatch (JSON-RPC error)

Changes:
- validateToolInput now throws plain Error so input validation stays
  tool-level (isError: true)
- validateToolOutput now uses InternalError code instead of InvalidParams
  (output validation failure is a server-side bug, not client fault)
- catch block re-throws any ProtocolError, matching python-sdk semantics
  and allowing tool handlers to deliberately throw protocol-level errors
@felixweinberger felixweinberger requested a review from a team as a code owner March 26, 2026 17:01
@changeset-bot
Copy link

changeset-bot bot commented Mar 26, 2026

🦋 Changeset detected

Latest commit: e6b8a57

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 26, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1769

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1769

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1769

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1769

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1769

commit: e6b8a57

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code change itself is small and logically sound, but this is an explicitly breaking change to core error handling in the tools/call handler that warrants human sign-off — particularly the design choice to keep MethodNotFound for task-required-without-task (vs InvalidParams), and whether the changeset should be minor given the breaking nature.

Extended reasoning...

Overview

This PR broadens the catch block in McpServer's tools/call handler from only re-throwing UrlElicitationRequired to re-throwing all ProtocolError instances. It also downgrades validateToolInput from throwing ProtocolError to plain Error (so input validation stays as isError: true), and changes output validation error codes from InvalidParams to InternalError. Documentation and tests are updated accordingly.

Security risks

No security concerns. The change affects error classification, not authorization or data exposure.

Level of scrutiny

This is a self-described breaking change to a core code path. The tools/call handler is exercised by every tool invocation. Consumers relying on result.isError to detect output validation failures or handler-thrown ProtocolErrors will see different behavior. The changeset marks it as minor but the PR description and migration docs both call it breaking — a human should verify the semver classification. The files are also covered by CODEOWNERS (@modelcontextprotocol/typescript-sdk).

Other factors

The two bug reports are both pre-existing issues not introduced by this PR. The code logic is correct and well-motivated (aligning with spec classification and Python SDK behavior). Tests are updated to match. Migration docs are thorough. The concern is purely about the design-level decisions that a maintainer should validate.

Comment on lines 334 to +336
// Return the final result
return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult;
const result = (await ctx.task.store.getTaskResult(taskId)) as CallToolResult;
await this.validateToolOutput(tool, result, request.params.name);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 validateToolOutput at line 336 runs unconditionally after the polling loop, but the loop (line 325) also exits on failed and cancelled status. When a failed task stores a result without isError: true and without structuredContent, and the tool has an outputSchema, validateToolOutput throws a misleading ProtocolError(InternalError, "has output schema but no structured content") — masking the actual task failure. Guard with if (task.status \!== "completed") before lines 335-336.

Extended reasoning...

What the bug is

After the polling loop at line 325, handleAutomaticTaskPolling unconditionally calls getTaskResult and validateToolOutput at lines 335-336. However, the while loop exits when task.status is any of completed, failed, or cancelled. For failed or cancelled tasks, calling validateToolOutput is semantically wrong — a failed task should surface its failure reason, not have its output validated against a schema.

The triggering code path

  1. A tool is registered with taskSupport: "optional", an outputSchema, and a task handler.
  2. A client calls this tool WITHOUT task augmentation (request.params.task is falsy).
  3. Execution enters handleAutomaticTaskPolling at line 193.
  4. The task handler runs, but the task eventually fails. The handler calls storeTaskResult with a result that has content but does NOT set isError: true and does NOT include structuredContent. This is valid — the task status field (failed) indicates failure, not the isError flag.
  5. The polling loop exits because task.status === "failed".
  6. At line 335, getTaskResult retrieves the stored result successfully.
  7. At line 336, validateToolOutput is called. It checks: outputSchema exists (yes), content in result (yes), result.isError (false — not set), result.structuredContent (undefined). It then throws ProtocolError(InternalError, "has an output schema but no structured content was provided").

Why existing guards do not prevent this

validateToolOutput has three early-return guards: (1) no outputSchema, (2) result is not a CallToolResult, (3) result.isError is true. For the described scenario, the tool has an outputSchema, the result is a CallToolResult with content, and isError is not set (since storeTaskResult does not enforce isError: true for failed tasks — confirmed by the test at inMemory.test.ts:535). None of the guards trigger.

Impact

The misleading ProtocolError about output schema masks the actual task failure. Instead of the client learning that the task failed (and why), it receives an opaque internal error about missing structured content. This is a narrow edge case requiring: taskSupport: "optional" + no task augmentation from client + task fails + result stored without isError: true + tool has outputSchema. But it was introduced by this PR (the validateToolOutput call at line 336 is new).

How to fix

Guard the getTaskResult/validateToolOutput calls with a status check:

if (task.status \!== "completed") {
    const result = await ctx.task.store.getTaskResult(taskId).catch(() => null);
    const message = result && "content" in result
        ? (result.content as Array<{text?: string}>)?.[0]?.text ?? `Task ${task.status}`
        : `Task ${task.status}`;
    throw new Error(message);
}

const result = (await ctx.task.store.getTaskResult(taskId)) as CallToolResult;
await this.validateToolOutput(tool, result, request.params.name);
return result;

Or more minimally, just guard validateToolOutput:

const result = (await ctx.task.store.getTaskResult(taskId)) as CallToolResult;
if (task.status === "completed") {
    await this.validateToolOutput(tool, result, request.params.name);
}
return result;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant